哈囉,各位邦友們!
今天要來學習單元測試 + E2E 測試,確保每次重構或新增功能都能放心使用。
hero-journey/src/app/hero.service.spec.ts 與 hero-detail.spec.ts 擴充實際測試案例。ng e2e smoke test,驗證整體流程。Angular CLI v20 預設使用 Karma + Jasmine。在 repo 根目錄執行:
npm run test
會啟動瀏覽器並 watch 檔案。
我們在 Day21 引入 signal 快取英雄清單,測試時需要替代 HTTP 並驗證快取行為。
以下範例直接覆寫 src/app/hero.service.spec.ts:
import { TestBed } from '@angular/core/testing';
import { Hero, HeroService } from './hero.service';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
describe('HeroService', () => {
  let service: HeroService;
  let http: HttpTestingController;
  const mockHeroes: Hero[] = [
    { id: 11, name: 'Dr Nice', rank: 'B', skills: ['Healing'] },
    { id: 12, name: 'Narco', rank: 'A', skills: ['Stealth'] },
  ];
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting()],
    });
    service = TestBed.inject(HeroService);
    http = TestBed.inject(HttpTestingController);
  });
  afterEach(() => {
    http.verify();
  });
  it('loadAll() 會把清單寫進 heroesState()', () => {
    let received: Hero[] | undefined;
    service.loadAll().subscribe((heroes) => (received = heroes));
    const req = http.expectOne('api/heroes');
    expect(req.request.method).toBe('GET');
    req.flush(mockHeroes);
    expect(received).toEqual(mockHeroes);
    expect(service.heroesState()).toEqual(mockHeroes);
  });
  it('getById() 會優先回傳快取資料', () => {
    service.loadAll().subscribe();
    http.expectOne('api/heroes').flush(mockHeroes);
    service.getById(11).subscribe();
    http.expectNone('api/heroes/11');
  });
  it('create() 會修剪輸入並把新英雄加入 signal', () => {
    service.create({ name: '  Shadow  ', rank: 'S', skills: ['Dash'] }).subscribe();
    const req = http.expectOne('api/heroes');
    expect(req.request.method).toBe('POST');
    expect(req.request.body).toEqual({ name: 'Shadow', rank: 'S', skills: ['Dash'] });
    req.flush({ id: 99, name: 'Shadow', rank: 'S', skills: ['Dash'] });
    expect(service.heroesState().some((hero) => hero.id === 99)).toBeTrue();
  });
});
重點拆解:
provideHttpClientTesting() 提供 HttpTestingController,讓我們能攔截並檢查 HTTP 請求。http.expectNone() 擔保快取命中時不會多送一支請求。heroesState() 是只讀 signal,直接呼叫即可確認是否同步更新。Day22 把 HeroDetail 改寫成 rxResource(),測試上需要 stub 服務並檢查畫面條件渲染。
更新 src/app/hero-detail/hero-detail.spec.ts:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HeroDetail } from './hero-detail';
import { HeroService } from '../hero.service';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { of, throwError } from 'rxjs';
describe('HeroDetail', () => {
  let fixture: ComponentFixture<HeroDetail>;
  let component: HeroDetail;
  let heroService: jasmine.SpyObj<HeroService>;
  beforeEach(async () => {
    heroService = jasmine.createSpyObj('HeroService', ['getById']);
    await TestBed.configureTestingModule({
      imports: [HeroDetail],
      providers: [
        { provide: HeroService, useValue: heroService },
        provideRouter([], withComponentInputBinding()),
      ],
    }).compileComponents();
    fixture = TestBed.createComponent(HeroDetail);
    component = fixture.componentInstance;
  });
  it('顯示英雄資訊並產出 avatar URL', () => {
    heroService.getById.and.returnValue(
      of({ id: 12, name: 'Narco', rank: 'A', skills: ['Stealth'] })
    );
    fixture.componentRef.setInput('id', 12);
    fixture.detectChanges();
    const heading: HTMLElement = fixture.nativeElement.querySelector('h2');
    const avatar: HTMLImageElement = fixture.nativeElement.querySelector('img');
    expect(heading.textContent).toContain('Narco');
    expect(avatar.src).toContain('Narco'.toLowerCase());
  });
  it('服務丟出錯誤時顯示錯誤訊息', () => {
    heroService.getById.and.returnValue(throwError(() => new Error('Boom')));
    fixture.componentRef.setInput('id', 99);
    fixture.detectChanges();
    const banner: HTMLElement = fixture.nativeElement.querySelector('app-message-banner');
    expect(banner.textContent).toContain('Boom');
  });
  it('reload() 會重新觸發資料載入', () => {
    heroService.getById.and.returnValues(
      of({ id: 11, name: 'Dr Nice' }),
      of({ id: 11, name: 'Dr Nice', rank: 'B' })
    );
    fixture.componentRef.setInput('id', 11);
    fixture.detectChanges();
    expect(heroService.getById).toHaveBeenCalledTimes(1);
    component.reload();
    fixture.detectChanges();
    expect(heroService.getById).toHaveBeenCalledTimes(2);
  });
});
重點說明:
fixture.componentRef.setInput() 取代路由輸入。provideRouter([]) 讓模板中的 routerLink 正常運作。querySelector) 確認模板輸出,避免只驗證內部 private 狀態。Angular CLI 目前在 ng e2e 找不到目標時會詢問要安裝哪個方案。選擇 Playwright(對應套件 playwright-ng-schematics),專案會自動新增:
playwright.config.ts
tests/example.spec.ts
package.json 的 e2e script 以及 Playwright 相關依賴執行初始命令:
ng e2e
首次執行會提示安裝 Playwright 及瀏覽器,可依指示完成 npx playwright install。
接著在 tests/example.spec.ts 放入以下測試,覆蓋「搜尋 + 詳細頁」的測試
import { test, expect } from '@playwright/test';
test('heroes search smoke flow', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('link', { name: 'Heroes' }).click();
  await expect(page).toHaveURL(/\/heroes$/);
  await page.getByLabel('Search heroes').fill('Narco');
  await expect(page.getByText('命中 1 位英雄')).toBeVisible();
  await page.getByRole('link', { name: 'View' }).first().click();
  await expect(page).toHaveURL(/detail\/\d+/);
  await expect(page.getByRole('heading', { level: 2 })).toContainText('Narco');
});
今日小結:
今天實作了單元測試接住資料層與 UI 狀態,確保 Signals 與 Reactive Forms 的行為不被改壞。
也學會透過 Playwright 端對端測試讓我們在部署、升級相依套件後仍能快速確認主線流程。
測試成為與效能、部署同等重要的一環,讓 累積的成果有持續演進的底氣。
參考資料: